10장: 장기 실행 작업 (Long-Running Operations)
10장에서 다루는 내용
- 즉각적이지 않은 API 호출에 장기 실행 작업을 사용하는 방법
- 작업 진행에 따른 메타데이터 저장
- 리소스 계층상의 작업 위치
- 폴링과 블로킹을 통한 작업 상태(에러 결과를 포함한) 탐색
- 실행 작업 상호작용(예: 일시 정지, 재개, 취소)
10.1 서론 — 왜 장기 실행 작업이 필요한가?
핵심 키워드
| 용어 | 설명 |
|---|---|
| 동기적(Synchronous) | 요청을 보내면 결과가 올 때까지 기다리는 방식. 전화 통화처럼 바로 응답을 받음 |
| 비동기적(Asynchronous) | 요청을 보내고 나중에 결과를 받는 방식. 택배 주문처럼 주문 후 기다림 |
| LRO (Long-Running Operation) | 오래 걸리는 작업을 추적하기 위한 API 리소스. "택배 운송장 번호"와 같은 역할 |
| API 프라미스 | LRO의 다른 이름. 프로그래밍의 Promise처럼 "나중에 결과를 줄게"라는 약속 |
문제 상황
지금까지 본 API 메서드는 모두 즉각적이고 동기적이었다. 요청하면 바로 결과가 돌아왔다.
하지만 현실에서는 오래 걸리는 작업이 있다: - 외부 서비스에 접속하는 작업 - 기가바이트/테라바이트 단위 데이터 처리 - 복잡한 분석이나 변환 작업
비유: 식당에서 주문하기
동기적 API = 패스트푸드점
"햄버거 주세요" → (30초 후) → "여기요!"
✅ 바로 받을 수 있어서 편하다
비동기적 API = 고급 레스토랑
"스테이크 주세요" → "주문번호 42번입니다"
→ (30분 후) "42번 손님, 준비됐습니다!"
✅ 기다리는 동안 다른 일을 할 수 있다
기존 방식의 문제점
리스트 10.1 — 기존 동기 방식의 문제
function main() {
let client = ChatRoomApiClient();
let chatRoomName = generateChatRoomName();
console.log(`Generated name ${chatRoomName}`);
// ⚠️ 이 호출이 오래 걸리면?
// 사용자는 "버그인가?" 하고 프로세스를 종료해버린다!
let chatRoom = client.CreateChatRoom({
name: chatRoomName
});
console.log(`Created ChatRoom ${chatRoom.id}`);
}
문제들: - 응답이 얼마나 걸릴지 예측 불가 - 작업 진행 과정을 확인할 수 없음 - 완료된 작업을 취소할 방법 없음 - 로컬 프로세스를 중단해도 서버에 알릴 방법 없음
10.2 개요 — LRO는 프로그래밍의 Promise와 같다
핵심 키워드
| 용어 | 설명 |
|---|---|
| Promise (프라미스) | JavaScript에서 "아직 결과가 없지만, 나중에 줄게"라는 객체 |
| Future (퓨처) | Python 등에서 Promise와 같은 개념 |
| 플레이스홀더 | 진짜 결과가 올 때까지 자리를 차지하는 임시 객체 |
| 이행(Resolve) | 작업이 성공적으로 끝나서 결과를 반환하는 것 |
| 거부(Reject) | 작업이 실패해서 에러를 반환하는 것 |
Promise vs LRO 비교
프로그래밍 Promise API의 LRO
───────────────────── ─────────────────────
로컬 프로세스 안에서만 존재 서버에 영구적으로 저장됨
프로세스 종료하면 사라짐 프로세스 종료해도 남아있음
코드에서 직접 생성 API 메서드 호출 시 자동 생성
정해진 결과 타입만 반환 결과 + 메타데이터 추적 가능
Promise 코드 예제
// 리스트 10.2 — 프라미스 대기 및 콜백 연결
// 방법 1: await로 기다리기
async function waitOnPromise() {
let factors = await factor(...); // 결과가 나올 때까지 대기
let promise = factor(...); // 바로 실행, promise 반환
let otherFactors = await promise; // 나중에 결과 받기
}
// 방법 2: 콜백으로 처리하기
function promiseCallback() {
let promiseForFactors = factor(...);
promiseForFactors.then( (factors: number[]) => {
// 성공 시 처리
}).catch( (err) => {
// 실패 시 처리
});
}
LRO가 Promise와 다른 3가지 핵심 차이점
┌─────────────────────────────────────────────────────────────┐
│ LRO vs Promise 핵심 차이 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1️⃣ 반환 타입이 다르다 │
│ Promise: 직접 결과 반환 (ChatRoom) │
│ LRO: Operation<ChatRoom, Metadata> 반환 │
│ │
│ 2️⃣ 영속성이 다르다 │
│ Promise: 프로세스 안에서만 존재 (일시적) │
│ LRO: API 서버에 저장됨 (영속적, 고유 ID 보유) │
│ │
│ 3️⃣ 메타데이터 추적 가능 │
│ Promise: 결과만 반환 │
│ LRO: 진행률, 시작시각, 예상완료시간 등 추적 │
│ │
└─────────────────────────────────────────────────────────────┘
LRO 전체 흐름 (시퀀스 다이어그램)
사용자 API 프론트엔드 작업 저장소 백엔드 서버
│ │ │ │
│ backupData() │ │ │
│────────────────>│ │ │
│ │ CreateOperation()│ │
│ │─────────────────>│ │
│ │ Operation{...} │ │
│ │<─────────────────│ │
│ Operation{...} │ │ │
│<────────────────│ │ BackupData() │
│ │ │────────────────>│
│ │ │ 202 Accepted │
│ │ │<────────────────│
│ │ │ │
│ ┌──────────── 백업 진행 중 ──────────────┐ │
│ │ │ │ │ │
│ │ │ 백엔드 상태 업데이트 │ │
│ │ │ │ │ │
│ │ │ UpdateOperation({ done: false }) │
│ │ │ │<────────────────│
│ │ │ │ │
│ │ [사용자 폴링] │ │ │
│ │ GetOperation() │ │
│ │─────────────>│ GetOperation() │ │
│ │ │─────────────────>│ │
│ │ │ Operation{...} │ │
│ │ │<─────────────────│ │
│ │ Operation{ │ │ │
│ │ done: false │ │ │
│ │ } │ │ │
│ │<─────────────│ │ │
│ └──────────────────────────────────┘ │
│ │ │ │
│ ┌──────────── 작업 완료 ────────────────┐ │
│ │ │ UpdateOperation({ done: true }) │
│ │ │ │<────────────────│
│ │ │ │ │
│ │ GetOperation() │ │
│ │─────────────>│─────────────────>│ │
│ │ │ Operation{ │ │
│ │ │ done: true, │ │
│ │ │ result: {...} │ │
│ │ │ } │ │
│ │<─────────────│<─────────────────│ │
│ └──────────────────────────────────┘ │
10.3 구현
LRO를 구현하려면 두 가지가 필요하다: 1. Operation 리소스 정의 (결과 타입 + 메타데이터 인터페이스) 2. LRO와 상호작용하는 API 메서드 정의 (get, list, wait, cancel, pause, resume)
10.3.1 LRO의 형태 — Operation 인터페이스
핵심 키워드
| 용어 | 설명 |
|---|---|
| Operation | LRO를 표현하는 리소스. 작업의 상태, 결과, 메타데이터를 담는 그릇 |
| 제네릭 매개변수화 | Operation<ResultT, MetadataT>처럼 타입을 유동적으로 지정하는 방법 |
| done 플래그 | 작업 완료 여부를 나타내는 불리언 값. true면 끝남 |
| OperationError | LRO 실패 시 에러 정보를 담는 인터페이스 (code, message, details) |
// 리스트 10.4 — Operation 및 OperationError 인터페이스 정의
interface Operation<ResultT, MetadataT> {
id: string; // 고유 식별자
done: boolean; // 완료 여부
result?: ResultT | OperationError; // 성공 결과 또는 에러
metadata?: MetadataT; // 진행 사항 메타데이터
}
interface OperationError {
code: string; // 기계가 읽는 에러 코드 (예: "TIMEOUT")
message: string; // 사람이 읽는 에러 메시지
details?: any; // 추가 세부 정보
}
비유: Operation은 "택배 운송장"
┌─────────────────────────────────────┐
│ 📦 운송장 (Operation) │
│ │
│ 운송장번호(id): "op-12345" │
│ 배송완료?(done): false │
│ 현재상태(metadata): │
│ "서울 물류센터 → 대전 이동 중" │
│ 결과(result): 아직 없음 │
│ │
│ ──── 나중에 배송 완료되면 ──── │
│ │
│ 배송완료?(done): true │
│ 결과(result): 상품 수령 완료! │
└─────────────────────────────────────┘
LRO의 3가지 상태 (Promise와 동일)
┌──────────┐
│ pending │ ← 대기 중 (done=false, result 없음)
│ (대기) │
└────┬─────┘
│
┌───────┴───────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ resolved │ │ rejected │
│ (이행) │ │ (거부) │
│ done=true│ │ done=true│
│ result= │ │ result= │
│ 성공결과 │ │ OpError │
└──────────┘ └──────────┘
ResultT는 반드시 API 리소스일 필요 없다
- 작업 완료 사실만 전달 → 빈 값 반환
- 번역 결과 →
TranslationResult같은 임시 인터페이스 - 메타데이터가 불필요 → MetadataT를 null로 설정 가능
10.3.2 리소스 계층 — Operation은 어디에 저장할까?
핵심 키워드
| 용어 | 설명 |
|---|---|
| 중첩 리소스 | 특정 리소스 하위에 넣는 방식 (예: chatRooms/1/operations/2) |
| 최상위 컬렉션 | 모든 Operation을 한곳에 모으는 방식 (예: /operations/1) |
두 가지 선택지 비교
방법 1: 중첩 (❌ 권장하지 않음)
chatRooms/1/operations/op-1
chatRooms/2/operations/op-2
⚠️ 문제점:
- 여러 리소스에 걸친 작업은? (텍스트→오디오 변환)
- 전체 시스템의 작업 목록을 보려면? (각각 조회해야 함)
방법 2: 최상위 컬렉션 (✅ 권장)
/operations/op-1
/operations/op-2
✅ 장점:
- 한 번에 전체 LRO 검색 가능
- 하나의 LRO만 가리킬 수 있음
- 여러 리소스에 걸친 작업도 문제 없음
LRO를 반환하는 API 메서드 예시
// 리스트 10.3 — create 표준 메서드가 LRO를 반환
abstract class ChatRoomApi {
@post("/chatRooms")
CreateChatRoom(req: CreateChatRoomRequest):
Operation<ChatRoom, CreateChatRoomMetadata>;
// ↑ ChatRoom 대신 Operation을 반환!
// 최종적으로 ChatRoom 리소스가 됨
}
10.3.3 이행 — 결과를 어떻게 받을까?
결과를 얻는 2가지 방법: 폴링(Polling)과 대기(Waiting/Blocking)
폴링 (Polling) — 주기적으로 물어보기
핵심 키워드
| 용어 | 설명 |
|---|---|
| 폴링(Polling) | 클라이언트가 주기적으로 "끝났어?" 하고 확인하는 방식 |
| 지수 백오프(Exponential Backoff) | 폴링 간격을 점점 늘리는 전략 (1초→2초→4초→8초...) |
| GetOperation | Operation의 현재 상태를 조회하는 표준 메서드 |
비유: 택배 배송 추적
📱 "내 택배 어디야?" (1분 후)
🚚 "서울 물류센터입니다"
📱 "내 택배 어디야?" (2분 후) ← 간격을 점점 늘림
🚚 "대전 지나는 중입니다"
📱 "내 택배 어디야?" (4분 후)
🚚 "배달 완료! 🎉"
// 리스트 10.5 — 폴링으로 LRO 완료 확인
async function createChatRoomAndWait(): Promise<ChatRoom> {
let operation: Operation<ChatRoom, CreateChatRoomMetadata>;
// 1단계: 작업 시작 → Operation 반환
operation = CreateChatRoom({
resource: { title: "Cool Chat" }
});
// 2단계: done=true가 될 때까지 반복 확인
while (!operation.done) {
operation = GetOperation<ChatRoom, CreateChatRoomMetadata>({
id: operation.id // 매번 최신 상태를 가져옴
});
// 서버 부담을 줄이기 위해 1초 대기
await new Promise((resolve) => {
setTimeout(resolve, 1000)
});
}
// 3단계: 최종 결과 반환
return operation.result as ChatRoom;
}
GetOperation API 정의
// 리스트 10.6 — get 표준 메서드 정의
abstract class ChatRoomApi {
@post("/chatRooms")
CreateChatRoom(req: CreateChatRoomRequest):
Operation<ChatRoom, CreateChatRoomMetadata>;
@get("/{id=operations/*}")
GetOperation<ResultT, MetadataT>(req: GetOperationRequest):
Operation<ResultT, MetadataT>;
// 매개변수화되어 모든 종류의 작업에 사용 가능
}
interface GetOperationRequest {
id: string;
}
폴링의 장단점
✅ 장점 ❌ 단점
───────────────────── ─────────────────────
가장 간단하고 이해하기 쉬움 요청이 계속 반복됨 (낭비)
클라이언트가 타이밍을 제어 결과를 즉시 알 수 없음
정확한 시점에 결과 요청 가능 네트워크 트래픽 증가
대기 (Waiting/Blocking) — 서버가 알려줄 때까지 기다리기
핵심 키워드
| 용어 | 설명 |
|---|---|
| WaitOperation | 서버에 연결을 유지하며 작업 완료까지 기다리는 사용자 메서드 |
| 블로킹(Blocking) | 응답이 올 때까지 연결을 열어두는 방식. 서버가 완료 시 바로 응답 |
| HTTP Long Polling | 서버가 응답을 보류하다가 이벤트 발생 시 보내는 기법 |
비유: 음식점 진동벨
폴링 = "다 됐어요?" 직접 카운터에 계속 물어봄 😤
대기 = 진동벨 받고 앉아 있다가 울리면 가져옴 😊
// 리스트 10.7 — wait 사용자 메서드 정의
abstract class ChatRoomApi {
@get("/{id=operations/*}:wait") // 멱등성을 위해 HTTP GET 사용
WaitOperation<ResultT, MetadataT>(req: WaitOperationRequest):
Operation<ResultT, MetadataT>;
}
interface WaitOperationRequest {
id: string;
}
// 리스트 10.8 — WaitOperation을 사용한 간결한 클라이언트 코드
function createChatRoomAndWait(): Promise<ChatRoom> {
let operation: Operation<ChatRoom, CreateChatRoomMetadata>;
// 1단계: 작업 시작
operation = CreateChatRoom({
resource: { title: "Cool Chat" }
});
// 2단계: 완료까지 대기 (반복문 불필요!)
operation = WaitOperation({ id: operation.id });
// 3단계: 결과 반환
return operation.result as ChatRoom;
}
폴링 vs 대기 비교
┌──────────────┬───────────────────┬───────────────────────┐
│ │ 폴링 │ 대기 │
├──────────────┼───────────────────┼───────────────────────┤
│ 제어 주체 │ 클라이언트 │ 서버 │
│ 네트워크 요청 │ 여러 번 │ 1번 │
│ 결과 즉시성 │ 약간의 지연 │ 거의 즉시 │
│ 연결 끊김 시 │ 다시 폴링하면 됨 │ 처음부터 다시 시작 │
│ 구현 복잡도 │ 클라이언트가 복잡 │ 서버가 복잡 │
│ 진행률 확인 │ 매 폴링마다 가능 │ 완료 시에만 확인 │
└──────────────┴───────────────────┴───────────────────────┘
주의: 대기 방식에서 연결이 끊기면 처음부터 폴링을 시작해야 할 수 있다.
10.3.4 에러 처리
핵심 키워드
| 용어 | 설명 |
|---|---|
| OperationError | LRO 실패 시 반환되는 에러 객체 (code, message, details) |
| 에러 코드(code) | 기계가 읽을 수 있는 고유한 에러 식별자 (예: "TIMEOUT", "INVALID_INPUT") |
| 에러 메시지(message) | 사람이 읽을 수 있는 설명. 코드로 분기하면 안 됨! |
| 세부 필드(details) | 구조적인 추가 에러 정보를 담는 필드 |
LRO 에러 vs 일반 HTTP 에러
일반 API 에러:
GET /chatRooms/999 → 404 Not Found (즉각적 HTTP 에러)
LRO 에러:
GET /operations/op-1 → 200 OK (Operation 조회 자체는 성공!)
{
"id": "op-1",
"done": true,
"result": { ← result 안에 에러가 들어있음
"code": "ANALYSIS_FAILED",
"message": "채팅방에 메시지가 없습니다",
"details": { "chatRoom": "chatRooms/1" }
}
}
중요: GetOperation이 에러를 반환하는 것과 LRO 작업 자체가 에러인 것은 다르다!
// 리스트 10.9 — 에러 여부 확인하고 결과를 반환하는 예제
function isOperationError(result: any):
result is OperationError {
const err = result as OperationError;
return err.code !== undefined && err.message !== undefined;
}
function createChatRoomAndWait(): Promise<ChatRoom> {
let operation: Operation<ChatRoom, CreateChatRoomMetadata>;
operation = CreateChatRoom({ resource: { title: "Cool Chat" } });
operation = WaitOperation({ id: operation.id });
if (isOperationError(operation.result)) {
// 에러 처리: 로그 남기기, 재시도, 사용자에게 알림 등
// ...
} else {
return operation.result as ChatRoom;
}
}
에러 코드 설계 원칙
✅ 좋은 에러 설계 ❌ 나쁜 에러 설계
───────────────────── ─────────────────────
code: "QUOTA_EXCEEDED" message만 있고 code 없음
message: "일일 한도 초과" → 기계가 파싱해야 함
details: { limit: 100 } → 메시지 바꾸면 코드 깨짐
10.3.5 진행 사항 모니터링 — 메타데이터 활용
핵심 키워드
| 용어 | 설명 |
|---|---|
| MetadataT | Operation의 제네릭 매개변수. 작업의 진행 정보를 담는 인터페이스 |
| 진행률(Progress) | 작업이 얼마나 완료됐는지 나타내는 값. 반드시 퍼센트일 필요 없음 |
메타데이터 인터페이스 예시
// 리스트 10.10 — AnalyzeMessages 사용자 메서드 정의
abstract class ChatRoomApi {
@post("/{parent=chatRooms/*}/messages:analyze")
AnalyzeMessages(req: AnalyzeMessagesRequest):
Operation<MessageAnalysis, AnalyzeMessagesMetadata>;
}
interface AnalyzeMessagesRequest {
parent: string; // 예: "chatRooms/1"
}
// 결과 인터페이스
interface MessageAnalysis {
chatRoom: string;
messageCount: number;
participantCount: number;
userGradeLevels: map<string, number>;
}
// 메타데이터 인터페이스 (진행 사항 추적)
interface AnalyzeMessagesMetadata {
chatRoom: string;
messagesProcessed: number; // 처리된 메시지 수
messagesCounted: number; // 세어진 메시지 수
}
진행 사항을 활용한 클라이언트 코드
// 리스트 10.11 — 메타데이터를 사용한 진행 사항 표시
async function analyzeMessagesWithProgress(): Promise<MessageAnalysis> {
let operation: Operation<MessageAnalysis, AnalyzeMessagesMetadata>;
operation = AnalyzeMessages({ parent: 'chatRooms/1' });
while (!operation.done) {
operation = GetOperation<MessageAnalysis, AnalyzeMessagesMetadata>({
id: operation.id
});
// 메타데이터에서 진행 사항 추출
let metadata = operation.metadata as AnalyzeMessagesMetadata;
console.log(
`Processed ${metadata.messagesProcessed} of ` +
`${metadata.messagesCounted} messages counted...`
);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return operation.result as MessageAnalysis;
}
실행 결과 예시:
Processed 0 of 150 messages counted...
Processed 23 of 150 messages counted...
Processed 67 of 150 messages counted...
Processed 150 of 150 messages counted...
✅ 분석 완료!
진행률 표현 방법
⚠️ 퍼센트만으로는 부족하다!
문제점:
- 퍼센트가 역행할 수 있음 (80% → 75%)
- 의미있는 값이 아닐 수 있음
✅ 더 나은 방법:
- 처리된 레코드 수 (23/150)
- 처리된 바이트 수 (1.2GB/5GB)
- 작업 단계 ("데이터 수집 중" → "분석 중" → "결과 생성 중")
메타데이터 필드는 아무 인터페이스에나 담을 수 있으므로
작업에 맞는 가장 적절한 방식을 선택하면 됨
10.3.6 작업 취소 (CancelOperation)
핵심 키워드
| 용어 | 설명 |
|---|---|
| CancelOperation | 진행 중인 LRO를 취소하는 사용자 메서드 |
| 정리(Cleanup) | 취소 후 중간 결과물(생성된 파일 등)을 제거하는 과정 |
취소가 필요한 상황
비유: 인쇄소에 책 1000부 주문했는데...
📞 "아, 잠깐! 표지 디자인이 틀렸어요! 중단해주세요!"
🏭 "네, 지금까지 200부 찍었는데 중단하겠습니다."
🏭 "200부는 폐기 처리하겠습니다." ← 정리(cleanup)
// 리스트 10.12 — cancel 사용자 메서드 정의
abstract class ChatRoomApi {
@post("/{id=operations/*}:cancel")
CancelOperation<ResultT, MetadataT>(req: CancelOperationRequest):
Operation<ResultT, MetadataT>;
// 취소 완료 후 Operation 반환 (done=true)
}
interface CancelOperationRequest {
id: string;
}
취소의 핵심 규칙
1. 취소도 시간이 걸릴 수 있다
→ WaitOperation으로 취소 완료까지 대기 가능
2. 작업이 시작되지 않았던 것처럼 되돌려야 한다
→ 중간에 생성된 파일, 데이터 등을 정리(cleanup)
3. 정리가 불가능하면 메타데이터에 남긴다
→ 사용자가 직접 정리할 수 있도록 정보 제공
4. 모든 LRO가 취소를 지원할 필요는 없다
→ 로켓 발사 API에서 일시 정지? 말이 안 됨!
→ 이점이 없으면 지원하지 않는 것이 맞음
10.3.7 작업 일시 정지 및 재개 (Pause / Resume)
핵심 키워드
| 용어 | 설명 |
|---|---|
| PauseOperation | 진행 중인 LRO를 잠시 멈추는 사용자 메서드 |
| ResumeOperation | 일시 정지된 LRO를 다시 시작하는 사용자 메서드 |
| paused 필드 | 메타데이터에 추가하는 불리언 필드. 일시 정지 상태를 나타냄 |
일시 정지 상태를 어디에 저장할까?
선택지 1: Operation에 새 필드 추가 ❌
- 모든 작업에 일시정지가 필요하지 않음
- done 필드로는 일시정지를 표현할 수 없음
선택지 2: 메타데이터에 paused 불리언 추가 ✅
- 필요한 작업만 paused 필드를 가짐
- 유연하고 확장 가능
// 리스트 10.13 — 메타데이터에 paused 필드 추가
interface AnalyzeMessagesMetadata {
chatRoom: string;
paused: boolean; // ← 일시 정지 상태
messagesProcessed: number;
messagesCounted: number;
}
// 리스트 10.14 — pause 및 resume 사용자 메서드 정의
abstract class ChatRoomApi {
@post("/{id=operations/*}:pause")
PauseOperation<ResultT, MetadataT>(req: PauseOperationRequest):
Operation<ResultT, MetadataT>;
@post("/{id=operations/*}:resume")
ResumeOperation<ResultT, MetadataT>(req: ResumeOperationRequest):
Operation<ResultT, MetadataT>;
}
interface PauseOperationRequest { id: string; }
interface ResumeOperationRequest { id: string; }
Pause/Resume 핵심 규칙
1. 두 메서드 모두 적합하고 실행 가능하게 API를 구성해야 함
2. 일시 정지/재개가 성공한 경우에만 결과 반환
3. 데이터 복사 중 일시 정지 → 복사 포인터 저장 + 프로세스 중단
4. 실제 구현 세부 사항은 API 디자이너에 달려있음
10.3.8 작업 탐색 (ListOperations)
핵심 키워드
| 용어 | 설명 |
|---|---|
| ListOperations | 모든 Operation을 목록으로 조회하는 표준 메서드 |
| 필터링(Filtering) | 특정 조건의 Operation만 골라보는 기능 (예: "미완료 작업만") |
왜 탐색이 필요한가?
상황: 프로세스가 죽었다!
1. 코드에서 LRO를 시작함
2. 폴링 도중에 프로세스 충돌!
3. 작업 ID를 메모리에만 저장했으므로... 사라짐 💀
해결: ListOperations로 진행 중인 작업을 검색!
// 리스트 10.15 — list 표준 메서드 정의
abstract class ChatRoomApi {
@get("/operations")
ListOperations<ResultT, MetadataT>(req: ListOperationsRequest):
ListOperationsResponse<ResultT, MetadataT>;
}
interface ListOperationsRequest {
filter: string; // 필터 조건 (예: "done=false")
}
interface ListOperationsResponse<ResultT, MetadataT> {
results: Operation<ResultT, MetadataT>[];
}
필터링 활용 예시
"현재 진행 중인 작업은?"
→ filter: "done=false"
"일시 정지된 작업은?"
→ filter: "metadata.paused=true"
"특정 채팅방의 분석 작업은?"
→ filter: "metadata.chatRoom=chatRooms/1"
10.3.9 영속성 — LRO는 언제까지 보관할까?
핵심 키워드
| 용어 | 설명 |
|---|---|
| 영속성(Persistence) | 데이터를 영구적으로 저장하는 것 |
| expireTime | Operation 리소스의 만료 시점. 이 시간이 지나면 삭제됨 |
| 롤링 윈도우 삭제 | 작업 완료 시점 기준으로 일정 기간 후 삭제하는 정책 |
LRO의 특수한 영속성
일반 리소스 (ChatRoom):
- 명시적으로 생성됨 (CreateChatRoom)
- 영구적으로 존재
- delete 메서드로 삭제
LRO (Operation):
- 다른 작업의 부산물로 자동 생성됨
- 완료 후에는 중요도가 급격히 떨어짐
- 영구 보관하면 스토리지 낭비!
영속성 정책 3가지
1. 영구 보관 (가장 단순)
✅ 구현이 쉽다
❌ 수백만 개가 쌓이면 성능 문제
2. 롤링 윈도우 삭제 (✅ 권장)
완료 후 30일 뒤에 자동 삭제
expireTime 필드로 만료 시점 표시
3. 복잡한 규칙 기반 삭제 (비추천)
연결된 리소스 삭제 시 같이 삭제 등
❌ 혼동의 여지가 높고 알고리즘이 복잡
// 리스트 10.16 — Operation 리소스에 만료 필드 추가
interface Operation<ResultT, MetadataT> {
id: string;
done: boolean;
expireTime: Date; // ← 만료 시점 (예: 30일 후)
result?: ResultT | OperationError;
metadata?: MetadataT;
}
중요: 작업 결과 타입에 따라 만료 시간이 달라지면 안 된다! 동일한 작업이면 동일한 만료 시간을 사용해야 혼란을 방지할 수 있다.
10.3.10 최종 API 정의
// 리스트 10.17 — LRO에 관한 최종 API 정의
abstract class ChatRoomApi {
// 사용자 메서드: 메시지 분석 (LRO 반환)
@post("/{parent=chatRooms/*}/messages:analyze")
AnalyzeMessages(req: AnalyzeMessagesRequest):
Operation<MessageAnalysis, AnalyzeMessagesMetadata>;
// 표준 메서드: 작업 조회
@get("/{id=operations/*}")
GetOperation<ResultT, MetadataT>(req: GetOperationRequest):
Operation<ResultT, MetadataT>;
// 표준 메서드: 작업 목록 조회
@get("/operations")
ListOperations<ResultT, MetadataT>(req: ListOperationsRequest):
ListOperationsResponse<ResultT, MetadataT>;
// 사용자 메서드: 작업 완료까지 대기
@get("/{id=operations/*}:wait")
WaitOperation<ResultT, MetadataT>(req: WaitOperationRequest):
Operation<ResultT, MetadataT>;
// 사용자 메서드: 작업 취소
@post("/{id=operations/*}:cancel")
CancelOperation<ResultT, MetadataT>(req: CancelOperationRequest):
Operation<ResultT, MetadataT>;
// 사용자 메서드: 작업 일시 정지
@post("/{id=operations/*}:pause")
PauseOperation<ResultT, MetadataT>(req: PauseOperationRequest):
Operation<ResultT, MetadataT>;
// 사용자 메서드: 작업 재개
@post("/{id=operations/*}:resume")
ResumeOperation<ResultT, MetadataT>(req: ResumeOperationRequest):
Operation<ResultT, MetadataT>;
}
// ═══════════════════════════════════════
// Operation 관련 인터페이스
// ═══════════════════════════════════════
interface Operation<ResultT, MetadataT> {
id: string;
done: boolean;
expireTime: Date;
result?: ResultT | OperationError;
metadata?: MetadataT;
}
interface OperationError {
code: string;
message: string;
details?: any;
}
// ═══════════════════════════════════════
// 요청/응답 인터페이스
// ═══════════════════════════════════════
interface GetOperationRequest { id: string; }
interface ListOperationsRequest { filter: string; }
interface ListOperationsResponse<ResultT, MetadataT> {
results: Operation<ResultT, MetadataT>[];
}
interface WaitOperationRequest { id: string; }
interface CancelOperationRequest { id: string; }
interface PauseOperationRequest { id: string; }
interface ResumeOperationRequest { id: string; }
interface AnalyzeMessagesRequest { parent: string; }
// ═══════════════════════════════════════
// 비즈니스 인터페이스
// ═══════════════════════════════════════
interface MessageAnalysis {
chatRoom: string;
messageCount: number;
participantCount: number;
userGradeLevels: map<string, number>;
}
interface AnalyzeMessagesMetadata {
chatRoom: string;
paused: boolean;
messagesProcessed: number;
messagesCounted: number;
}
10.4 트레이드오프
LRO를 도입하면 좋은 점과 주의할 점
✅ 장점
─────────────────────────────────────────
- 오래 걸리는 작업을 비동기적으로 처리
- 작업 진행 사항 추적 가능
- 일시 정지, 재개, 취소 가능
- 클라이언트가 다른 작업을 하면서 대기 가능
⚠️ 트레이드오프
─────────────────────────────────────────
1. 충분한 시간을 주는 것이 더 간단할 수 있다
→ 분산 구조에 잘 맞지 않는 경우도 있음
2. 연결이 끊기면 진행 사항 모니터링이 복잡해짐
→ 네트워크 장애 후 모니터링 재개 = LRO급 복잡도
3. 제네릭 매개변수화 리소스는 복잡하다
→ 명시적 생성이 아닌, 다른 작업에 딸려서 생성됨
→ 기존 리소스 생성과 다른 패턴
4. LRO와 '재수행 가능 작업'은 다르다
→ 11장에서 자세히 다룸
5. 사용이 강제되지 않는다
→ 동기적 API가 필요하면 그냥 동기적으로 사용하면 됨
10.5 연습문제
문제 1: 왜 작업 리소스를 대상 리소스 하위에 중첩시키지 않고 최상위 리소스로 생성해야 하는가?
답:
- 여러 리소스에 걸친 작업을 표현하기 어려움 (예: 텍스트→오디오 변환은 어느 리소스 하위?)
- 시스템 전체 작업 목록을 만들기 어려움 (chatRooms/1/operations + chatRooms/2/operations + ... 각각 조회 필요)
- 최상위 /operations 컬렉션이면 한 번의 ListOperations 호출로 전체 작업 검색 가능
문제 2: 왜 LRO를 만료시켜야 하는가?
답:
- LRO는 명시적 생성이 아닌 부산물이므로 무한히 쌓임
- 완료된 LRO는 시간이 지나면 중요도가 급격히 떨어짐
- 영구 보관하면 스토리지 부담이 가중됨
- expireTime 기반의 롤링 윈도우 삭제가 가장 깔끔한 해법
문제 3: 사용자가 작업 완료를 기다리는 도중에 cancel 사용자 메서드에 의해 중단됐다면 어떤 결과를 얻게 되는가?
답:
- CancelOperation은 작업을 완전히 취소한 뒤 Operation을 done=true로 설정하여 반환
- 대기(WaitOperation) 중이었다면 취소가 완료되면 응답을 받게 됨
- result에는 OperationError가 담기고, 코드는 취소를 나타내는 값(예: "CANCELLED")
- 중간 결과물이 정리되지 않으면 메타데이터에 정리해야 할 정보가 포함됨
문제 4: LRO 진행 사항을 추적할 경우 단일 필드를 사용해 완료 퍼센트 값을 저장하는 것이 적절한가? 그 이유는 무엇인가?
답: - 적절하지 않다 - 퍼센트 완료율은 역행할 수 있음 (80% → 75%) → 사용자 혼란 - 의미 있는 값이라는 보장이 없음 (예: 새로운 데이터가 발견되면 전체 양이 바뀜) - 더 나은 방법: 처리된 레코드 수/전체 레코드 수, 처리된 바이트양 등 구체적인 메타데이터 사용 - 메타데이터 인터페이스는 자유롭게 설계 가능하므로, 작업에 맞는 의미 있는 필드를 넣는 것이 좋음
요약
- LRO는 웹 API의 프라미스 또는 퓨처에 해당하며, API 서비스에 의해 백그라운드에서 진행되는 작업의 완료 상태를 추적하는 도구다.
- LRO는 매개변수화 인터페이스이며, 특정 결과 타입(예: 작업 결과 리소스) 및 작업 진행 사항 정보를 담은 메타데이터 타입을 반환한다.
- LRO는 주어진 시간 뒤에 결과 또는 에러를 반환하며, 사용자는 주기적으로 상태 업데이트를 폴링하거나 대기하며 통보를 받는 식으로 이를 확인할 수 있다.
- API 내의 사용자 메서드를 사용하면 LRO를 임의로 일시 정지, 재개, 취소할 수 있다.
- LRO는 스토리지에 보관하되, 예를 들어 30일 등 설정한 시간이 지나면 만료 처리해야 한다.
부록 A: Flask/Python으로 구현하는 LRO 서버
"""
장기 실행 작업(LRO) 패턴을 Flask로 구현한 예제.
채팅방 메시지 분석 작업을 LRO로 처리하는 API 서버.
"""
from flask import Flask, jsonify, request
import uuid
import time
import threading
from datetime import datetime, timedelta
app = Flask(__name__)
# 작업 저장소 (실제로는 DB 사용)
operations = {}
# ═══════════════════════════════════════
# Operation 관련 헬퍼
# ═══════════════════════════════════════
def create_operation(metadata=None):
"""새 Operation 리소스를 생성한다."""
op_id = f"operations/{uuid.uuid4().hex[:8]}"
operations[op_id] = {
"id": op_id,
"done": False,
"expireTime": (datetime.now() + timedelta(days=30)).isoformat(),
"result": None,
"metadata": metadata
}
return operations[op_id]
def complete_operation(op_id, result):
"""작업을 성공 상태로 완료한다."""
if op_id in operations:
operations[op_id]["done"] = True
operations[op_id]["result"] = result
def fail_operation(op_id, code, message, details=None):
"""작업을 실패 상태로 완료한다."""
if op_id in operations:
operations[op_id]["done"] = True
operations[op_id]["result"] = {
"code": code,
"message": message,
"details": details
}
# ═══════════════════════════════════════
# 백그라운드 작업 시뮬레이션
# ═══════════════════════════════════════
def analyze_messages_background(op_id, chat_room):
"""
백그라운드에서 메시지 분석을 수행한다.
실제로는 무거운 연산이 여기서 실행됨.
"""
total_messages = 150
for i in range(0, total_messages + 1, 30):
time.sleep(2) # 무거운 작업 시뮬레이션
op = operations.get(op_id)
if not op:
return
# 취소 확인
meta = op.get("metadata", {})
if meta.get("cancelled"):
fail_operation(op_id, "CANCELLED", "사용자에 의해 취소됨")
return
# 일시 정지 확인 — paused면 풀릴 때까지 대기
while meta.get("paused"):
time.sleep(1)
meta = operations[op_id].get("metadata", {})
# 진행 사항 업데이트
operations[op_id]["metadata"]["messagesProcessed"] = i
operations[op_id]["metadata"]["messagesCounted"] = total_messages
# 작업 완료
complete_operation(op_id, {
"chatRoom": chat_room,
"messageCount": total_messages,
"participantCount": 12,
"userGradeLevels": {"초급": 5, "중급": 4, "고급": 3}
})
# ═══════════════════════════════════════
# API 엔드포인트
# ═══════════════════════════════════════
@app.route("/chatRooms/<room_id>/messages:analyze", methods=["POST"])
def analyze_messages(room_id):
"""메시지 분석 LRO를 시작한다."""
chat_room = f"chatRooms/{room_id}"
op = create_operation(metadata={
"chatRoom": chat_room,
"paused": False,
"messagesProcessed": 0,
"messagesCounted": 0
})
# 백그라운드 스레드에서 실제 작업 시작
thread = threading.Thread(
target=analyze_messages_background,
args=(op["id"], chat_room)
)
thread.daemon = True
thread.start()
return jsonify(op), 202 # 202 Accepted
@app.route("/operations/<op_id>", methods=["GET"])
def get_operation(op_id):
"""Operation의 현재 상태를 조회한다 (폴링)."""
full_id = f"operations/{op_id}"
op = operations.get(full_id)
if not op:
return jsonify({"code": "NOT_FOUND", "message": "작업을 찾을 수 없습니다"}), 404
return jsonify(op)
@app.route("/operations/<op_id>:wait", methods=["GET"])
def wait_operation(op_id):
"""Operation이 완료될 때까지 블로킹하며 대기한다."""
full_id = f"operations/{op_id}"
op = operations.get(full_id)
if not op:
return jsonify({"code": "NOT_FOUND", "message": "작업을 찾을 수 없습니다"}), 404
# 서버 측 폴링으로 완료까지 대기
while not op["done"]:
time.sleep(0.5)
op = operations.get(full_id)
return jsonify(op)
@app.route("/operations/<op_id>:cancel", methods=["POST"])
def cancel_operation(op_id):
"""진행 중인 작업을 취소한다."""
full_id = f"operations/{op_id}"
op = operations.get(full_id)
if not op:
return jsonify({"code": "NOT_FOUND", "message": "작업을 찾을 수 없습니다"}), 404
if op["done"]:
return jsonify({"code": "ALREADY_DONE", "message": "이미 완료된 작업입니다"}), 400
# 메타데이터에 취소 플래그 설정
op["metadata"]["cancelled"] = True
return jsonify(op)
@app.route("/operations/<op_id>:pause", methods=["POST"])
def pause_operation(op_id):
"""진행 중인 작업을 일시 정지한다."""
full_id = f"operations/{op_id}"
op = operations.get(full_id)
if not op:
return jsonify({"code": "NOT_FOUND", "message": "작업을 찾을 수 없습니다"}), 404
op["metadata"]["paused"] = True
return jsonify(op)
@app.route("/operations/<op_id>:resume", methods=["POST"])
def resume_operation(op_id):
"""일시 정지된 작업을 재개한다."""
full_id = f"operations/{op_id}"
op = operations.get(full_id)
if not op:
return jsonify({"code": "NOT_FOUND", "message": "작업을 찾을 수 없습니다"}), 404
op["metadata"]["paused"] = False
return jsonify(op)
@app.route("/operations", methods=["GET"])
def list_operations():
"""모든 Operation을 목록으로 조회한다. filter 파라미터로 필터링 가능."""
filter_str = request.args.get("filter", "")
results = list(operations.values())
# 간단한 필터링 구현
if "done=false" in filter_str:
results = [op for op in results if not op["done"]]
elif "done=true" in filter_str:
results = [op for op in results if op["done"]]
return jsonify({"results": results})
if __name__ == "__main__":
app.run(port=5001, debug=True)
부록 B: LRO 클라이언트 사용 예제 (Python)
"""
LRO API를 사용하는 클라이언트 예제.
위의 Flask 서버가 실행 중이어야 한다.
"""
import requests
import time
BASE_URL = "http://localhost:5001"
def analyze_with_polling():
"""폴링 방식으로 메시지 분석 결과를 가져온다."""
print("=== 폴링 방식 ===")
# 1단계: 분석 작업 시작
resp = requests.post(f"{BASE_URL}/chatRooms/1/messages:analyze")
operation = resp.json()
print(f"작업 시작: {operation['id']}")
# 2단계: 완료까지 폴링
while not operation["done"]:
time.sleep(2)
resp = requests.get(f"{BASE_URL}/{operation['id']}")
operation = resp.json()
meta = operation.get("metadata", {})
print(f" 진행: {meta.get('messagesProcessed', 0)}"
f"/{meta.get('messagesCounted', 0)} 메시지 처리됨")
# 3단계: 결과 확인
result = operation["result"]
if "code" in result:
print(f"에러 발생: {result['code']} - {result['message']}")
else:
print(f"분석 완료! 메시지 {result['messageCount']}개, "
f"참여자 {result['participantCount']}명")
def analyze_with_waiting():
"""대기(블로킹) 방식으로 메시지 분석 결과를 가져온다."""
print("\n=== 대기 방식 ===")
# 1단계: 분석 작업 시작
resp = requests.post(f"{BASE_URL}/chatRooms/1/messages:analyze")
operation = resp.json()
print(f"작업 시작: {operation['id']}")
# 2단계: 서버가 알려줄 때까지 대기 (한 번의 요청!)
print(" 서버 응답 대기 중...")
resp = requests.get(f"{BASE_URL}/{operation['id']}:wait")
operation = resp.json()
# 3단계: 결과 확인
result = operation["result"]
print(f"분석 완료! 메시지 {result['messageCount']}개")
if __name__ == "__main__":
analyze_with_polling()
analyze_with_waiting()
부록 C: 전체 API 메서드 한눈에 보기
┌───────────────────┬────────┬─────────────────────────────────────┐
│ 메서드 │ HTTP │ 엔드포인트 │
├───────────────────┼────────┼─────────────────────────────────────┤
│ AnalyzeMessages │ POST │ /{parent=chatRooms/*}/messages: │
│ │ │ analyze │
│ GetOperation │ GET │ /{id=operations/*} │
│ ListOperations │ GET │ /operations │
│ WaitOperation │ GET │ /{id=operations/*}:wait │
│ CancelOperation │ POST │ /{id=operations/*}:cancel │
│ PauseOperation │ POST │ /{id=operations/*}:pause │
│ ResumeOperation │ POST │ /{id=operations/*}:resume │
└───────────────────┴────────┴─────────────────────────────────────┘
참고:
- GetOperation, WaitOperation은 GET (멱등성, 상태 변경 없음)
- CancelOperation, PauseOperation, ResumeOperation은 POST (상태 변경)
- 모든 Operation 메서드는 제네릭 매개변수화 <ResultT, MetadataT>
클릭하거나 Space를 눌러 뒤집기